Skip to content
快速预览

渲染引擎 和 js 引擎互斥

✍️ w 🕒 2023-07-17 00:13:38(10 months ago) 🔗 A.前端知识整理

GUI渲染线程与JavaScript引擎是互斥的。这意味着当JavaScript引擎正在执行代码时,GUI渲染线程会被挂起,无法执行。这样做是为了避免JavaScript代码与页面渲染之间的冲突。在JavaScript引擎执行期间,所有的GUI更新操作会被保存在一个队列中。当JavaScript引擎执行完毕并空闲下来时,队列中的GUI更新操作会立即被执行,以确保页面渲染的正确性。

图 7

通过下面案例可以更容易看出来阻塞问题,下面代码并没有按照我们所想,将 <div>1</div> 改成 <div> 修改</div>因为JavaScript 文件的下载过程会阻塞 DOM 解析,这也就是为什么如果是dom 操作你需要将 script 写在下面即使不写在下面也是写成 window.onload 才能操作dom,整个过程发生在DOM树构建过程中,如果遇到JavaScript代码,浏览器会执行这些代码。在执行过程中,可能会对DOM树进行修改。注意,JS执行会阻止接下来的渲染过程,直到JS执行完成。

html
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Document</title>
		<script>
			let div1 = document.getElementsByTagName('div')[0]
			div1.innerText = '修改'
		</script>
	</head>
	<body>
		<div>1</div>

		<div>test</div>
	</body>
</html>

在看一个计数的案例,两个JavaScript方法(updateSync和updateAsync)在执行时的不同表现。当运行updateSync方法时,页面上的按钮会立刻显示999,而运行updateAsync方法时,按钮上的数字会逐渐增加。

这种现象可以通过JavaScript引擎线程和GUI渲染线程之间的互斥关系来解释。在updateSync方法执行过程中,GUI渲染线程会等待JavaScript引擎线程执行完毕,因此按钮上的数字会直接变成999。而在updateAsync方法执行过程中,GUI渲染线程可以在JavaScript引擎线程执行过程中更新按钮上的数字,因此按钮上的数字会逐渐增加。这种差异可以通过图像更直观地展示。

js
<div id="output"></div>

<button onclick="updateSync ()">Run Sync</button>

<button onclick="updateAsync ()">Run Async</button>

<script>

function updateSync() {
    for (var i = 0; i < 1000; i++) {
        document.getElementById('output').innerHTML = i;
    }
}

function updateAsync() {
    var i = 0;

    function updateLater() {
        document.getElementById('output').innerHTML = (i++);
        if (i < 1000) {
            setTimeout(updateLater, 0);
        }
    }

    updateLater();
}
</script>

css 和 DOM

CSS不阻塞dom的生成。两者同步互不影响在构成的时候 图 6

如图,网络进程接收到返回的 HTML 数据之后,将其发送给渲染进程,渲染进程会解析 HTML 数据并构建 DOM。就是在 DOM 构建结束之后,一些link css 文件还没有下载完成的时候,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM。这个空闲时间有可能成为页面渲染的瓶颈。

图 1

但是CSS在特定情况下会阻塞dom的生成,在浏览器渲染网页时,首先会将 HTML 转换为 DOM(文档对象模型),然后将 CSS 转换为 CSSOM(CSS 对象模型)。在这个过程中,如果页面中包含了外部 CSS 文件的引用,或者通过 style 标签内置了 CSS 内容,那么渲染引擎需要先将这些内容转换为 CSSOM。

由于 JavaScript 有修改 CSSOM 的能力,所以在执行 JavaScript 脚本之前,还需要依赖 CSSOM。这意味着在某些情况下,CSS 会阻塞 DOM 的生成。换句话说,如果 CSS 没有完全加载和解析,浏览器将暂停 DOM 的生成,直到 CSSOM 准备就绪,然后再执行 JavaScript 脚本。这样可以确保 JavaScript 在操作 DOM 时,CSS 样式已经正确应用。

下面代码作为说明来看:

html
<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
    <script src='foo.js'></script>
    <div>geekbang com</div>
</body>
</html>

在接收到 HTML 数据之后的(渲染进程接收 HTML 文件字节流时,会先开启一个预解析线程,如果遇到 JavaScript 文件或者 CSS 文件,那么预解析线程会提前下载这些数据)过程中,HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,然后就同时发起这两个文件的下载请求,需要注意的是,这两个文件的下载过程是重叠的,所以下载时间按照最久的那个文件来算。

后面的流水线就和前面是一样的了,不管 CSS 文件和 JavaScript 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JavaScript 脚本,最后再继续构建 DOM,构建布局树,绘制页面。

也就是说 css 阻塞了 js ,js 在又阻塞了dom ,间接的 css 阻塞了dom

图 2

为什么 css 会阻止js

JavaScript 等待 CSS 而不是像 DOM 那样阻塞 DOM 的原因主要是为了确保页面的样式正确渲染。当浏览器解析 HTML 和 CSS 时,它会生成 DOM 和 CSSOM。这两个对象模型都是渲染引擎用来构建最终页面的基础。

JavaScript 可以操作 DOM 和 CSSOM,比如修改元素的样式、内容和结构。如果在 CSSOM 完全构建之前就执行 JavaScript,那么可能会出现以下问题:

  1. 页面样式不正确:JavaScript 可能会在 CSSOM 完全构建之前修改元素的样式,这样就会导致页面样式不正确,用户体验受到影响。

  2. 重排和重绘:如果 JavaScript 在 CSSOM 构建过程中执行,可能会导致浏览器频繁地进行重排(重新计算元素的布局)和重绘(重新绘制元素),这会消耗更多的性能,导致页面加载速度变慢。

为了避免这些问题,浏览器会等待 CSSOM 完全构建之后再执行 JavaScript。这样可以确保在 JavaScript 操作 DOM 和 CSSOM 时,页面的样式已经正确应用,从而提高页面的渲染性能和用户体验。

为什么加载script 会阻止dom

当HTML解析器遇到<script>标签时,它会暂停对HTML文档的解析,然后加载、解析并执行其中的JavaScript代码。这是因为JavaScript可以通过诸如document.write()等方法来修改DOM结构,从而改变文档的形状。为了确保正确处理这些更改,HTML解析器需要等待JavaScript代码执行完成后,才能继续解析HTML文档。

为什么不要将 script 标签放到头部

  1. <head>中放置的<script>元素会阻塞页面的渲染过程因为上面说过,当 JS 引擎执行时,GUI 线程会被挂起。因此把 JavaScript 放在<head>里,意味着必须把所有 JavaScript 代码都下载、解析和解释完成后,才能开始渲染页面。对应问题:外部脚本加载时间很长(比如一直无法完成下载),就会造成网页长时间失去响应,浏览器就会呈现“假死”状态,用户体验会变得很糟糕

  2. 将 JavaScript 脚本放在<body>的最后面。这样可以避免资源阻塞,页面得以迅速展示

  3. 也可使用 defer/async 做标记

早期采取优化

CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积

关于defer/async

遇到 <script src='xxx/xxx.js'>,会阻碍GUI的渲染, 因此 增加了两个属性 defer/async ,了解之前需要知道一个概念,script 这里需要两部分来看,第一部分是 下载,第二部分是 执行,一个文件的下载过程不会阻止其他文件的下载和执行,但一个文件的执行过程会阻止其他文件的执行(js 和 渲染引擎互斥),而不会阻止其他文件的下载。下载过程 也分为同步和异步,同步只能下载完一个文件才能下载另一个,异步加载允许浏览器同时下载多个文件以及执行, 但是中间要注意但是无论下载时什么方式,但是文件执行只能一次一个

因此,为了优化这个过程,可以将下载和执行问题拆解,从而实现更高效的加载和执行。关键在于合理安排何时触发执行,以充分利用浏览器的资源。

先通过案例在 script 引入 index.js 其中 index.js 是一个从10000打印到0的一个方法,下面是四种不同形式的使用,案例说明当你在你本地想查看同等案例的时候需要一次仅放开一种情况来查看效果,四种情况效果

  1. 第一种情况 script 放置顶部,运行后 <p>我在开始渲染</p> 没有立刻渲染
  2. 第二种情况 script 增加了'defer'属性,运行后 <p>我在开始渲染</p> 立刻渲染
  3. 第三种情况 script 增加了'async'属性,运行后 <p>我在开始渲染</p> 立刻渲染
  4. 第四种情况 script 放到底部,运行后 <p>我在开始渲染</p> 立刻渲染
html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <!-- 第一种 -->
    <script  src="index.js"></script> 
    <!-- 第二种 -->
    <script defer src="index.js"></script>
    <!-- 第三种 -->
    <script async src="index.js"></script>
</head>
<body>
    <p>我在开始渲染</p>
    <!-- 第四种 -->
    <script  src="index.js"></script>
</body>
</html>
  • index.js
js
let count = 10000
while (count) {
    count -= 1
    console.log(count);
}

上面面过程中第一种 和 第四种情况他们都是 下载和执行 全部阻碍html渲染,并且浏览器一次只能执行一个文件,这导致了JS 引擎执行时,GUI 线程会被挂起,遇到'script' 下载对应资源,下载后解析资源,在重新渲染html,但由于第一种和第四种位置放置区别,产生了两种不同效果,它效果如图 图 8

关于第二种和第三种需要具体来看

Async -- 异步

图 11

  1. async属性允许浏览器在执行其他操作的同时并行下载指定的JavaScript资源。这意味着在下载JS文件时,浏览器不会阻塞其他操作,提高了页面加载的效率。但是,一旦JS文件下载完成,浏览器将立即执行它,这可能会阻止HTML文件的当前渲染。

  2. 使用async属性加载的脚本执行顺序是不可预测的。例如,如果有三个脚本分别将一个数字记录到控制台中,使用async加载时,它们的执行顺序可能会发生变化,导致输出的数字顺序不确定。

图 12

  1. async属性不能保证脚本在DOMContentLoaded事件之前或之后执行。这意味着,当使用async加载脚本时,我们无法确保脚本在页面的某个特定时刻执行,可能会导致一些依赖于特定执行时机的问题。
  • 如果脚本在DOMContentLoaded之前执行,那么它可能无法访问或操作尚未加载和解析的页面元素,从而导致脚本执行失败或出现错误。

  • 如果脚本在DOMContentLoaded之后执行,那么它可能会错过一些关键的初始化操作,导致页面功能不完整或出现异常。

defer-- 延迟

图 10

  1. 当在JS文件中使用defer属性时,浏览器会与其他文件一起下载这个JS文件,但是不会立即执行它。相反,它会等到HTML文件完全呈现后才开始执行。这与async属性不同,后者会在资源下载完成后立即执行。使用defer属性的好处是,它不会阻塞页面的渲染过程。

  2. 使用defer属性的多个JS文件将按照它们在HTML文件中的顺序依次加载和执行。这保证了脚本之间的依赖关系得到正确处理。

  3. defer属性使得JS文件在DOM树构建完成后、DOMContentLoaded事件触发之前执行。这意味着,当脚本开始执行时,页面的DOM结构已经完全加载,可以被脚本安全地访问和操作。

图 9

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="./js/test.js" defer></script>
  <script src="./js/demo.js" defer></script>
</head>
<body>
  
  <div id="app">app</div>
  <div class="box"></div>
  <div id="title">title</div>
  <div id="nav">nav</div>
  <div id="product">product</div>

  <!-- 1.下载需要很长的事件, 并且执行也需要很长的时间 -->
  <!-- 总结一: 加上defer之后, js文件的下载和执行, 不会影响后面的DOM Tree的构建 -->
  <script>
    // 总结三: defer代码是在DOMContentLoaded事件发出之前执行
    window.addEventListener("DOMContentLoaded", () => {
      console.log("DOMContentLoaded")
    })
  </script>
  
  <h1>哈哈哈哈啊</h1>

</body>
</html>

总结

  1. defer 和link是类似的机制了,不会阻碍GUI渲染,当GUI渲染完,才会把请求回来的JS去渲染
  2. async 请求JS资源是异步的「单独开辟HTTP去请求」,此时GUI继续渲染;但是一但当JS请求回来,会立即暂停GUI的处理,接下来去渲染JS
  3. 假如我们有5个JS的请求,如果不设置任何属性,肯定是按照顺序请求和渲染JS的「依赖关系是有效的」;但是如果设置async,谁先请求回来就先渲染谁,依赖关系是无效的;如果使用defer是可以建立依赖关系的(浏览器内部在GUI渲染完成后,等待所有设置defer的资源都请求回来,再按照编写的依赖顺序去加载渲染js);
  4. 真实项目开发,我们一般把link放在页面的头部「是为了在没有渲染DOM的时候,就通知HTTP去请求CSS了,这样DOM渲染玩,CSS也差不多回来了,更有效的利用时间,提高页面的渲染速度」;我们一般把JS放在页面的底部,防止其阻碍GUI的渲染,如果不放在底部,我们最好设置上async/defer;

还需要了解 这部分必看

DOMContentLoaded与load的区别

优化白屏

白屏是指在网页加载过程中,用户看到的页面为空白,这通常是由于网页渲染过程中的阻塞或延迟导致的。为了优化白屏时间,可以采取以下策略:

  1. 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了,减少 HTTP 请求 css 和js 文件请求,无需等待外部资源加载,需要注意的是,内联 JavaScript 和 CSS 的方法并非总是最佳实践。当你的 CSS 和 JavaScript 代码较大时,内联可能导致 HTML 文件过大,反而降低页面加载速度。此外,内联代码无法利用浏览器缓存机制,这意味着用户在访问其他页面时需要重新下载相同的代码

  2. 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。

  3. link 标签的 preload 属性:用于提前加载一些需要的依赖,这些资源会优先加载,preload 和 script 的作用是不同的。script 标签用于引入 JavaScript 脚本文件,浏览器在解析到 script 标签时会立即下载并执行对应的 JavaScript 代码。而 preload 属性则是用于指定需要预加载的资源,包括 JavaScript 脚本文件、CSS 样式表、图片等等,浏览器在解析到 preload 标签时会立即下载对应的资源,但不会执行其中的代码。使用 preload 属性指定了需要预加载的 JavaScript 脚本文件,但并没有执行其中的代码。如果需要执行其中的代码,还需要在页面中添加一个 script 标签来引入该脚本文件,这样,浏览器会先预加载 index.js 文件,然后在解析到 script 标签时再执行其中的代码。例如:

js
<link rel="preload" href="https://hzfe.org/index.js" as="script" />
<script src="https://hzfe.org/index.js"></script>
  1. 优化关键渲染路径:关键渲染路径是指浏览器从获取 HTML、CSS 和 JavaScript 文件到将它们解析、执行并渲染成可见页面的过程。优化关键渲染路径可以减少页面渲染所需的时间,从而减少白屏时间。这包括:

    • 压缩和最小化 HTML、CSS 和 JavaScript 文件。
    • 尽可能减少阻塞渲染的资源,如将 CSS 放在 <head> 中,将 JavaScript 放在 <body> 底部或使用 asyncdefer 属性。
    • 优先加载关键 CSS,将非关键 CSS 异步加载。
    • 使用内联 CSS 和内联 JavaScript(适用于较小的代码片段)。
  2. 服务器端渲染(SSR):通过在服务器端生成完整的 HTML 页面,可以减少浏览器端的渲染时间,从而减少白屏时间。这对于首次加载速度尤为重要。

  3. 骨架屏:在页面加载过程中,使用一个简单的骨架屏代替完整的页面内容。骨架屏通常包含页面的基本布局和占位符,可以让用户感知到页面正在加载,从而减轻白屏带来的不良体验。

  4. 使用浏览器缓存:合理利用浏览器缓存机制,将常用的静态资源(如 CSS、JavaScript、图片等)缓存到本地,以便在后续访问时加快页面加载速度。

  5. 延迟加载:对于非关键资源(如图片、视频等),可以使用延迟加载技术,如懒加载或预加载,以便在页面加载过程中优先渲染关键内容。

  6. 使用 Content Delivery Network(CDN):通过将静态资源部署到全球分布的 CDN 节点,可以加快资源的传输速度,从而减少白屏时间。

  7. 优化网络连接:使用 HTTP/2 协议可以减少网络延迟,提高资源加载速度。此外,还可以考虑使用预连接(preconnect)和预取(prefetch)技术来优化网络连接。

  8. 代码拆分:对于较大的 JavaScript 和 CSS 文件,可以使用代码拆分技术将它们分解成更小的模块,以便按需加载。这可以减少页面加载过程中的阻塞时间。

再快一点,再快一点 —— 优化博客白屏时间的实践 (一个优化案例的分析)

参考

浏览器工作原理与实践_李兵

如何减少白屏的时间

Async vs Defer vs Preload vs Server Push

How and when to use Async and Defer attributes

更快地构建DOM: 使用预解析, async, defer 以及 preload

Released under the MIT License.